/*
 * Copyright (c) 2011-2012 Alexander Dubu
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * o  Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * o  Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * o  Neither the name Axil nor the names of its contributors may be used to
 *    endorse or promote products derived from this software without specific
 *    prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */
package axil.stdlib.time.type;

import axil.api.AxilObject;
import axil.api.AxilType;
import axil.api.Formatting;
import axil.api.Persona;
import axil.api.error.AxilException;
import axil.stdlib.core.type.Atom;
import axil.stdlib.core.type.Nil;
import axil.stdlib.numbers.type.Num;

import java.util.Calendar;
import java.util.TimeZone;

import static axil.framework.Functions.*;


/**
 * An Axil representation of a date. Note that Axil uses a julian calendar,
 * not the Java Date object, to represent dates. This is intentional -- for the
 * novice script writer, julian dates behave in a way they can understand. The
 * Java Date class, while technically correct, behaves in so many non-intuitive
 * ways that users are likely to view the "correct" answers as wrong. So, we do
 * it the simply way to keep things simple.
 */
public class Date extends Atom implements Temporal {
	public static final AxilType type = Date_Type.object;
	private static final int[] days_in_month = {
		0,
		31,
		28,
		31,
		30,
		31,
		30,
		31,
		31,
		30,
		31,
		30,
		31
	};
    // Assumes an average of 30 days per month for calculations.
    public static final int DAYS_PER_MONTH = 30;

    /**
     * Month offsets for use in weekday calculations.
     */
    private static final int WEEKDAY_MONTH_OFFSET[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};

    private int month;
	private int day;
	private int year;
	private int jd;


	public Date(int month, int day, int year) {
		this.month = month;
		this.day = day;
		this.year = year;
		this.jd = julian(month, day, year);
	}


	public Date(int jd) {
		this.jd = jd;
		// http://en.wikipedia.org/wiki/Julian_day
		int j = (int)Math.floor((double)jd + 0.5D) + 32044;
		int g = j / 146097;
		int dg = j % 146097;
		int c = (((dg / 36524) + 1) * 3) / 4;
		int dc = dg - (c * 36524);
		int b = dc / 1461;
		int db = dc % 1461;
		int a = (((db / 365) + 1) * 3) / 4;
		int da = db - (a * 365);
		int y = g * 400 + c * 100 + b * 4 + a;
		int m = (da * 5 + 308) / 153 - 2;
        int d = da - (((m + 4) * 153) / 5) + 122;
        year = y - 4800 + (m + 2) / 12;
        month = (m + 2) % 12 + 1;
        day = d + 1;
	}


	/**
	 * Get the type metadata for this object. The type metadata contains
	 * information about the type itself, as an object.\
	 */
	public AxilType type() {
		return type;
	}


	private static int julian(int month, int day, int year) {
		// http://en.wikipedia.org/wiki/Julian_day
		int a = (14 - month) / 12;
		int y = year + 4800 - a;
		int m = month + (12 * a) - 3;
		int jd = day + (((153 * m) + 2) / 5) + (365 * y) + (y / 4) -
		         (y / 100) + (y / 400) - 32045;
		return jd;
	}


	public static Date today() {
		return from(new java.util.Date(System.currentTimeMillis()));
	}


	public static Date from(java.util.Date date) {
		return from(date, persona().timezone());
	}


	public static Date from(java.util.Date date, TimeZone tz) {
		Calendar cal = Calendar.getInstance();
		cal.setTimeZone(tz);
		cal.setTimeInMillis(date.getTime());
		return from(cal);
	}


	public static Date from(Calendar cal) {
		int year = cal.get(Calendar.YEAR);
		int month = cal.get(Calendar.MONTH) + 1;
		int day = cal.get(Calendar.DAY_OF_MONTH);
		return new Date(month, day, year);
	}


	public static boolean valid(int month, int day, int year) {
		boolean ok = between(month, 1, 12);
		if (ok) {
			if (month == 2) {
				ok = between(day, 1, leap(year) ? 29 : 28);
			} else {
				ok = between(day, 1, days_in_month[month]);
			}
		}
		return ok;
	}


	private static boolean leap(int year) {
		if ((year % 4) == 0) {
			if ((year % 100) == 0) {
				return (year % 400) == 0;
			} else {
				return true;
			}
		}
		return false;
	}


	public int julian() {
		return jd;
	}


	public int month() {
		return month;
	}


	public int day() {
		return day;
	}


	public int year() {
		return year;
	}


	public static int quarter(int month) {
		if ((month >= 1) && (month < 4))	return 1;
		if ((month > 3) && (month < 7))		return 2;
		if ((month > 6) && (month < 10))	return 3;
		if ((month > 9) && (month <= 12))	return 4;
		throw abort("There is no such month.", nv("month", month));
	}


	/**
	 * Extract from the temporal value the indicated unit. If the unit makes no
	 * sense for the type, then an exception is thrown.
	 */
	public int extract(TimeZone tz, Part part) {
		switch (part) {
		case day:
			return day();
		case month:
			return month();
		case quarter:
			return quarter(month());
		case year:
			return year();

		case weekday:
            /*
             * http://en.wikipedia.org/wiki/Determination_of_the_day_of_the_week
             * Using the Tomohiko Sakamoto algorithm for weekday calculation.
             */
            int m = month();
            int d = day();
            int y = year();
            if (m < 3) {
                y = y - 1;
            }
            int weekday = (y + y/4 - y/100 + y/400 + WEEKDAY_MONTH_OFFSET[m - 1] + d) % 7;
            // This algorithm results in zero-based weekdays; we want one-based, so adjusting here.
            return weekday + 1;

		default:
			throw error(axil(), "invalid-temporal-part",
			            nv("type", this.type()), nv("part", part.name()));
		}
	}


	/**
	 * Add the given value to this value, returning a new object. The given
	 * value is never null or nil. For a date, you can add any of the following
	 * types of objects:
	 *
	 * interval:	Returns a new date in the future based upon the value and
	 * 				units of the interval.
	 *
	 * number:		Assumes the value represents a number of days. Rounds the
	 * 				value to an integer and then performs the operation. Returns
	 * 				a new date that many days in the future.
	 *
	 * Passing any other type of object will result in an exception.
	 */
	public AxilObject add(AxilObject object) {
		if (object == Nil.object) {
			return Nil.object;
		}
		if (object instanceof Interval) {
			return future((Interval)object);
		}
		if (object instanceof Num) {
			Num n = (Num)object;
			Interval i = new Interval(n, Interval.type.days());
			return n.positive() ? future(i) : past(i);
		}
		throw error(axil(), "expecting-date-add", nv("actual", object.type()));
	}


	public Date future(Interval interval) {
		switch(interval.span()) {
		case day:
		case week:
			return new Date(julian() + interval.days());

		case month: {
			int months = (int)interval.magnitude().whole();
			int years = months / 12;
			if (years > 0) {
				months = months % 12;
			}
			int y = this.year() + years;
			int m = this.month() + months;
			if (m > 12) {
				y ++;
				m = m - 12;
			}
			Date d = new Date(m, day(y, m, this.day()), y);
			if (interval.magnitude().fractional()) {
				int days = (int)interval.magnitude().tenths() * DAYS_PER_MONTH;
				d = new Date(d.julian() + days);
			}
			return d;
		}

		case quarter:
			return future(interval.as(Interval.type.unit("month")));

		case year: {
			int years = (int)interval.magnitude().whole();
			int months = 0;
			if (interval.magnitude().fractional()) {
				months = (int)interval.magnitude().tenths() * 12;
			}
			int y = this.year() + years;
			int m = this.month() + months;
			if (m > 12) {
				y ++;
				m = m - 12;
			}
			return new Date(m, day(y, m, this.day()), y);
		}

		default:
			/*
			 * For any of the other intervals, they are either too fine
			 * grained to effect a date in a meaningful way or are converted
			 * into a whole number of days.
			 */
			int d = interval.days();
			return d > 0 ? new Date(julian() + d) : this;
		}
	}


	private int day(int y, int m, int d) {
		int max = days_in_month[m];
		if ((m == 2) && leap(y)) {
			max ++;
		}
		return Math.min(d, max);
	}



	/**
	 * Subtract the given object from this object. The given value is never
	 * null or nil. For a date, you can subtract the following types of objects:
	 *
	 * interval:	Returns a new date in the past based upon the value and
	 * 				units of the interval.
	 *
	 * number:		Assumes the value represents a number of days. Rounds the
	 * 				value to an integer and then performs the operation. Returns
	 * 				a new date that many days in the past.
	 *
	 * date:		Returns the difference, in days, between the two dates.
	 * 				The value returned is always an interval.
	 *
	 * timestamp:	Acts the same as a date, using only the date portion of the
	 * 				timestamp.
	 *
	 * Passing any other type of object will result in an exception.
	 */
	public AxilObject subtract(AxilObject object) {
		if (object == Nil.object) {
			return Nil.object;
		}
		if (object instanceof Interval) {
			return past((Interval)object);
		}
		if (object instanceof Num) {
			Num n = (Num)object;
			Interval i = new Interval(n, Interval.type.days());
			return n.positive() ? past(i) : future(i);
		}
		throw error(axil(), "expecting-date-subtract", nv("actual", object.type()));
	}


	public Date past(Interval interval) {
		switch(interval.span()) {
		case day:
		case week:
			return new Date(julian() - interval.days());

		case month: {
			int months = (int)interval.magnitude().whole();
			int years = months / 12;
			if (years > 0) {
				months = months % 12;
			}
			int y = this.year() - years;
			int m = this.month() - months;
			if (m < 1) {
				y --;
				m = m + 12;
			}
			Date d = new Date(m, this.day(), y);
			if (interval.magnitude().fractional()) {
				int days = (int)interval.magnitude().tenths() * DAYS_PER_MONTH;
				d = new Date(d.julian() - days);
			}
			return d;
		}

		case quarter:
			return future(interval.as(Interval.type.unit("month")));

		case year: {
			int years = (int)interval.magnitude().whole();
			int months = 0;
			if (interval.magnitude().fractional()) {
				months = (int)interval.magnitude().tenths() * 12;
			}
			int y = this.year() - years;
			int m = this.month() - months;
			if (m < 1) {
				y ++;
				m = m + 12;
			}
			return new Date(m, this.day(), y);
		}

		default:
			/*
			 * For any of the other intervals, they are either too find
			 * grained to effect a date in a meaningful way or are converted
			 * into a whole number of days.
			 */
			int d = interval.days();
			return d > 0 ? new Date(julian() - d) : this;
		}
	}


	/**
	 * Returns a hash code value for the object. All Axil values must provide a
	 * meaningful implementation of this function to enable themselves to be
	 * placed in a map or set.
	 */
	protected int hash() {
		return julian();
	}


	/**
	 * Tell if this object is equivalent to the given object. The object given
	 * is never null. The object given may be of any type of value object.  If
	 * the given object is not a suitable type for comparison, a
	 * ClassCastException may be thrown.
	 */
	public boolean equalTo(AxilObject object) {
		if (object instanceof Date) {
			Date other = (Date)object;
			return this.julian() == other.julian();
		}
		if (object instanceof Timestamp) {
			return equalTo(((Timestamp)object).date(persona().timezone()));
		}
		return false;
	}


	/**
	 * Compares this object with the specified object for order. If the given
	 * object is not a suitable type for comparison, a ClassCastException may
	 * be thrown.
	 *
	 * @param object
	 * 	The object to compare against. The given object cannot be null but may
	 * 	be any Axil object.
	 *
	 * @return
	 * 	Returns a negative integer, zero, or a positive integer as this object
	 * 	is less than, equal to, or greater than the specified object.
	 */
	public int comparedTo(AxilObject object) {
        if (object instanceof Date) {
            return julian() - ((Date)object).julian();
        }
        if (object instanceof Timestamp) {
            return compareTo(((Timestamp)object).date(persona().timezone()));
        }
        // "nil" comparison; always return 1 (nil less than everything).
        if (object == Nil.object) {
            return 1;
        }
        // Incorrect comparison.
        throw error(axil(), "order-comparison",
                nv("this", this.type()),
                nv("that", object.type()));
    }


	/**
	 * Return the host application (or built-in Java) object that most closely
	 * matches this value object. Return returned value is never null unless
	 * this value object represents the special 'nil' object.
	 */
	public Object intrinsic() {
		return date(persona().timezone());
	}


	/**
	 * Coerce this object into an instance of the given type. If this object
	 * cannot be meaningfully represented as an instance of that type, then an
	 * exception is thrown.
	 *
	 * @param type
	 * 	The type to be coerced to. The object given cannot be null.
	 *
	 * @return
	 * 	Returns an instance of the given type. If this object is nil, then nil
	 * 	is returned.
	 */
	public AxilObject coerce(AxilType type) throws AxilException {
		if (type == type()) {
			return this;
		}
		if (type == Timestamp.type) {
			return new Timestamp(this.date(persona().timezone()).getTime());
		}
		throw error(axil(), "cannot-coerce-object",
		            nv("from", type()), nv("to", type));
	}


	/**
	 * Convert this date object into a Java Date object using the given
	 * timezone.
	 */
	public java.util.Date date(TimeZone tz) {
		Calendar cal = Calendar.getInstance();
		cal.setTimeZone(tz);
		cal.set(Calendar.YEAR, year);
		cal.set(Calendar.MONTH, month - 1);
		cal.set(Calendar.DAY_OF_MONTH, day);
		cal.set(Calendar.HOUR_OF_DAY, 0);
		cal.set(Calendar.MINUTE, 0);
		cal.set(Calendar.SECOND, 0);
		cal.set(Calendar.MILLISECOND, 0);
		return cal.getTime();
	}


	/**
	 * Format this object in a locale-friendly manner. The string returned is
	 * a localized representation of this object using the locale and timezone
	 * associated with this persona. The formatting given is never null and
	 * matches formatting suitable for this type.
	 */
	public String format(Persona persona, Formatting formatting) {
		// TODO: Need better formatting
		return toString();
	}


	/**
	 * Return a string representation of this object. The string returned is
	 * never null.
	 */
	public String toString() {
		return Integer.toString(year) + '/' + left(month, 2) + '/' + left(day,
		        2);
	}
}
